~/nevie/blog / spring-boot-on-vps

Deploying Spring Boot on a Linux VPS with systemd and Nginx

· Nevie Technologies · 7 min read

Every Nevie platform is deployed the same way: a Spring Boot jar at a fixed path on disk, managed by systemd, behind Nginx. No Docker, no Kubernetes, no container orchestration overhead. Just a well-understood Linux service that survives reboots, restarts cleanly on failure, and can be updated with a two-command deploy script. This is exactly how we do it.

Directory layout

Each application gets its own directory under /srv/. The jar, external config, and website assets are intentionally separated so a content update never requires touching the jar.

/srv/nevie/
├── app.jar                         ← built by ./gradlew bootJar, copied here on deploy
├── config/
│   └── application.properties      ← prod config: www-root, base-url, cache settings
│                                      loaded via --spring.config.additional-location
└── www/                            ← external content root (never inside the jar)
    ├── home.html
    ├── about.html
    ├── solutions.html
    ├── products.html
    ├── developer.html
    ├── contact.html
    ├── fragments/
    │   └── head.html               ← the one Thymeleaf fragment
    ├── blog/
    │   └── spring-boot-on-vps/
    │       ├── blog.html
    │       └── images/
    ├── css/
    ├── js/
    └── images/

systemd unit file

Create /etc/systemd/system/nevie.service. The --spring.config.additional-location flag loads the external application.properties without replacing Boot's defaults — it overlays only the keys you specify.

[Unit]
Description=Nevie Technologies Corporate Portal
After=network.target mariadb.service
Wants=network.target

[Service]
Type=simple
User=nevie
Group=nevie
WorkingDirectory=/srv/nevie
ExecStart=/usr/bin/java \
    -Xms128m -Xmx256m \
    -jar /srv/nevie/app.jar \
    --spring.config.additional-location=file:/srv/nevie/config/
Restart=on-failure
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=nevie

[Install]
WantedBy=multi-user.target

Enable and start:

sudo systemctl daemon-reload
sudo systemctl enable nevie
sudo systemctl start nevie
sudo journalctl -u nevie -f          # tail live logs

Nginx reverse proxy

Nginx handles SSL termination, gzip compression, and static file headers. Spring Boot only sees plain HTTP on 127.0.0.1:8080. The X-Forwarded-* headers let Spring correctly construct redirect URLs and canonical links when it's behind the proxy.

server {
    listen 80;
    server_name nevie.xyz www.nevie.xyz;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name nevie.xyz www.nevie.xyz;

    ssl_certificate     /etc/letsencrypt/live/nevie.xyz/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/nevie.xyz/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers on;

    gzip on;
    gzip_vary on;
    gzip_proxied any;
    gzip_types text/plain text/css application/javascript
               application/json image/svg+xml;

    # Static assets: long cache (Spring Boot serves these with ETag support)
    location ~* \.(css|js|png|jpg|jpeg|gif|ico|woff2|woff|ttf|svg)$ {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        add_header Cache-Control "public, max-age=31536000, immutable";
        add_header Vary "Accept-Encoding";
    }

    # Everything else through Spring Boot
    location / {
        proxy_pass         http://127.0.0.1:8080;
        proxy_set_header   Host              $host;
        proxy_set_header   X-Real-IP         $remote_addr;
        proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto $scheme;
        proxy_read_timeout 60s;
        proxy_send_timeout 60s;
    }
}

Firewall with UFW

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow ssh
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable

Port 8080 is intentionally not opened externally — only Nginx reaches Spring Boot directly.

Zero-downtime deploy

Because the jar path is fixed and systemd restarts the process cleanly, a full application deploy is three commands. systemd's Restart=on-failure means the service recovers automatically from a bad jar (the old process is still running until the new one starts). Content-only updates (editing HTML in www/) need no restart at all — the filesystem template resolver picks up changes within the configured cache TTL.

#!/bin/bash
# deploy.sh — run from project root after a successful build

set -e
JAR=build/libs/app.jar

echo "Building..."
./gradlew bootJar -q

echo "Uploading jar..."
scp "$JAR" nevie@vps:/srv/nevie/app.jar

echo "Restarting service..."
ssh nevie@vps "sudo systemctl restart nevie"

echo "Tailing logs (Ctrl-C to exit)..."
ssh nevie@vps "sudo journalctl -u nevie -f --lines=40"

Production application.properties

Stored at /srv/nevie/config/application.properties — never committed to the repository. Overrides the dev defaults bundled in the jar.

nevie.content.www-root=/srv/nevie/www
nevie.content.blog-dir=/srv/nevie/www/blog
nevie.content.fragments-dir=/srv/nevie/www/fragments
nevie.content.template-cache-ttl-ms=60000

nevie.site.base-url=https://nevie.xyz

spring.thymeleaf.cache=true

server.compression.enabled=true
spring.web.resources.cache.cachecontrol.max-age=31536000
spring.web.resources.cache.cachecontrol.no-cache=false

logging.level.com.gsq.nevie=INFO

Why not Docker?

For a set of independently deployed Spring Boot services on a single VPS, the overhead of a container runtime adds complexity without adding isolation we don't already get from running separate systemd services as separate OS users. If the deployment target changes to a multi-node cluster, Docker or Podman makes sense. For a VPS running three or four focused services, systemd is simpler, faster to debug, and requires no daemon.